文章作者:Tyan
博客:noahsnail.com
第一章 引言
本书的目的是为了帮助你最有效的利用Java编程语言和它的基础库,java.lang
,java.util
,在更小程度上包括java.util.concurrent
和java.io
。本书有时会讨论其它的库,但不包括图形用户接口编程,企业APIs或移动设备。
本书包括七十八个条目,每个条目传达一条规则。这些规则通常是从实践中得到并且最好最有经验的程序员坚信它是有益的。这些条目被松散的分为十章,每章都是关于软件设计方面的一个扩展。本书不打算被从头到尾的读,每个条目或多或少都是依赖于它本身。这些条目之间的交叉引用非常严重,因此你可以很容易的通过本书划分自己的进度。
Java 5平台增加了许多新功能。本书中的大多数条目在某种程度上使用了这些功能。下表列出了这些新功能在本书中的位置:
大多数条目通过程序实例进行说明。本书的一个重要特点是它包含了说明许多设计模式和习惯用法的代码实例。这些条目放在哪里是合适的,它们被交叉参考引用到了这个领域的标准参考著作[Gamma 95]。
许多条目包含一个或多个用来表明一些应该在实践中避免的程序实例。这些例子中的都加上了清楚的注释例如“// Never do this!”,有时候这些例子也被称为反模式。在每一个例子中,这个条目都解释了为什么这个例子是不好的,并且提建议了一种可替代方法。
本书不是给初学者的:它假定你已经非常熟悉Java编程语言。如果你对Java语言不熟悉,请考虑许多很好的入门书籍中的一本[Arnold05, Sestoft05]。虽然本书的目标是任何具有实际Java编程经验的人,但它应该能提供一些思考的东西,即使是对于高级程序员。
本书中的大多数规则源于一些基本的原则。简洁清晰是最重要的。模块的用户不应该对它的行为感到惊奇。模块要尽可能的小但不是更小。(本书中使用的术语模块指的是任何可复用的软件组件,从单个方法到由多个包组成的复杂系统)。代码应该被复用而不是拷贝。模块间的依赖性要保持最小。错误应该尽早检测出来,理想情况是在编译时发现。
虽然本书中的规则不能百分百的应用于任何时间,但在大多数情况下具有最好编程实践的特征。你不应该盲从这些规则,但只是偶尔在有充足的理由的时候才违反这些规则。像大多数其它学科一样,学习编程艺术包括首先学习规则,然后学习在什么时候打破规则。
本书的大部分不是关于性能的。它是关于编写清晰、正确、可用、鲁棒、有弹性并且可维护的程序的。如果你能做到这一点,要得到你需要的性能它通常是相对简单的(条目55)。一些条目讨论性能的关注点,这些条目中的一些提供了性能指数。这些指数应该被看做与最好情况下近似,这些指数介绍时使用了词语”在我的机器上”。
值得注意的是,我的机器是老旧的组装电脑,2.2G赫兹双核AMD 皓龙处理器 170,2G内存,在微软的Windows XP SP2上运行Sun的JDK 1.6_05版本。JDK有两个虚拟机,Java热交换客户端和服务器虚拟机。性能指标是在服务器虚拟机上测量的。
当讨论Java编程语言的特性和它的库时,有时指明特定的版本是必要的。为了简洁,本书使用工程版本号而不是正式的发行名称。下表显示了发行名称与工程版本号的映射关系。
虽然这些例子是相当完整的,但它们注重可读性甚于完整性。他们可以很自由的使用包java.util
和java.io
中的类。为了编译这些例子,你可能必须添加一个或多个导入声明:
1 | import java.util.*; |
其它的例子中也有类似的省略情况。本书的网站:http://java.sun.com/docs/books/effective,含有每个例子的扩展版本,你可以编译并且运行。
本书中的大部分技术术语与Java语言规范(第三版)中的术语是一样的。一些术语需要特别指出。Java语言支持四种类型:接口(包括注解),类(包括枚举),数组和基本类型。前三个是引用类型。类实例和数组是对象,基本类型不是。类成员由它的域、方法、成员类和成员接口组成。方法的签名由它的名字、正式的参数类型组成;签名不包括方法的返回值类型。
本书使用了一些与Java语言规范不同的术语。不像Java语言规范,本书使用继承作为子类的同义词。不再使用接口继承的术语,本书简单表述一个类实现了一个接口或一个接口扩展了另一个接口。为了描述没有指定访问级别的情况,本书使用描述术语包私有代替技术上正确的术语缺省访问[JLS, 6.6.1].
本书使用一些Java语言规范没有定义的术语。术语exported API
或simply API
,指的是类、接口、构造函数、成员、序列化形式,程序员通过它们访问类、接口或包。(术语API,是应用程序接口的缩写,优先使用API而不是其他人更喜欢的术语接口,是为了避免与Java语言中的接口相混淆。)程序员写程序使用API指的是API的用户。类中实现使用了API的称为API的客户。
类、接口、构造函数、成员和序列化形式统称为API元素。导出API由定义API的包的包外能访问的API元素组成。这些API元素是任何客户都能使用的并且API的作者提供支持。无独有偶,Java工具类默认操作模式下也为这些元素产生了文档。不严格的说,包的导出API由公有成员、保护成员和每个公有类的构造函数或包中的接口组成。
第二章
这章是关于创建和销毁对象的:什么时候怎样创建它们,什么时候怎样避免创建它们,怎样确保它们被及时的销毁,怎么管理任何清理操作,清理操作必须在对象销毁之前。
Item 1: 考虑用静态工厂方法代替构造函数
一个类允许客户获得它本身的一个实例通常的方式是提供一个公有的构造函数。还有另一种技术应该成为每个程序员工具箱中的一部分。一个类可以提供一种公有的static factory method
,static factory method
是一种简单的静态方法,它会返回一个类的实例。这有一个来自Boolean(基本类型boolean的封装类)的简单例子。这个方法将一个布尔值转成Boolean对象的引用:
1 | public static Boolean valueOf(boolean b) { |
注意静态工厂方法与Design Patterns
中的Factory Method
是不同的。这个条目中描述的静态工厂方法与设计模式中的工厂方法是不等价的。
一个类可以为它的客户提供静态工厂方法来代替构造函数,或者除了构造函数之外再提供一个静态工厂方法。提供静态工厂方法代替公有构造函数既有优点也有缺点。
与构造函数相比,静态工厂方法的第一个优势是它们有名字。如果构造函数的参数本身不能描述返回的对象,具有合适名字的静态工厂是更容易使用的,并且产生的客户端代码更易读。例如,构造函数BigInteger(int, int, Random)
返回一个BigInteger
,这个BigInteger
可能是一个素数,使用名字为BigInteger.probablePrime
的静态工厂方法来表示会更好。(这个方法最终在1.4版本被引入。)
一个类只能有一个具有指定签名的构造函数。程序员知道怎样规避这个限制:通过提供两个构造函数,它们仅在参数列表类型的顺序上有所不同。这真的是一个坏主意。使用这种API的用户永远不能记住哪一个构造函数是哪一个,最后会无意中调用错误的构造函数。使用这些构造函数的人在读代码时如果没有类的参考文档将不知道代码要做什么。
因为静态工厂方法有名字,因此它们不会有上一段讨论的那种限制。当一个类似乎需要多个具有相同签名的构造函数时,用静态工厂方法代替构造函数,通过仔细选择工厂方法的名字来突出它们的不同。
与构造函数相比,静态工厂方法的第二个优势是当调用静态工厂方法时不要求每次都创建一个新的对象。这允许不可变类(Item 15)使用预创建的实例,或缓存构建好的实例,通过重复分发它们避免创建不必要的重复对象。Boolean.valueOf(boolean)
方法阐明了这个技术:它从未创建对象。这项技术与Flyweight模式类似[Gamma95, p. 195]。如果经常请求相同的对象,它能极大的提升性能,尤其是在创建对象的代价较昂贵时。
静态工厂方法能从重复的调用中返回相同的对象,在任何时候都能使类严格控制存在的实例。这些类被称为控制实例。编写控制实例类是有一些原因的。实例控制允许一个类保证它是一个单例(Item 3)或不可实例化的(Item 4)。它也允许一个不变的类(Item 15)保证不存在两个相等的实例:a.equals(b)
当且仅当a==b
。如果一个类保证了这一点,它的客户端可以使用==
操作符代替equals(Object)
方法,这可能会导致性能的提升。Enum类型(Item 30)保证了这一点。
与构造函数相比,静态工厂方法的第三个优势是它们能返回它们的返回类型的任意子类型的对象。这样在选择返回对象的类时有了更大的灵活性。
灵活性的一个应用是API能返回对象而不必使它们的类变成公有的。通过这种方式中隐藏实现类会有一个更简洁的API。这项技术适用于基于接口的框架(Item 18),接口为静态工厂方法提供了自然的返回类型。接口不能有静态方法,因此按惯例,命名为Type
的接口的静态工厂方法被放在一个命名为Types
的不可实例化的类中(Item 4)。
例如,Java集合框架有三十二个集合接口的便利实现,提供了不可修改的集合,同步集合等等。几乎所有的这些实现都是通过静态工厂方法导出在一个不可实例化的类中(java.util.Collections
)。返回对象的类都是非公有的。
集合框架API比它导出的三十二个分开的公有类更小,每一个便利实现对应一个类。它不仅仅是API的数量在减少,还是概念上意义上的减少。用户知道返回的对象含有接口指定的精确API,因此不需要阅读额外的实现类的文档。此外,使用这样的静态工厂方法需要客户端使用接口引用返回的对象而不是使用它的实现类,这通常是最佳的实践(Item 52)。
不仅公有静态工厂方法返回对象的类可以是非公有的,而且这个类还可以随着调用静态工厂时输入的参数值的变化而变化。声明的返回值类型的任何子类都是可以的。为了增强软件的可维护性及性能,返回值对象的类也可以随着发布版本的变化而变化。
在1.5版本中引入类java.util.EnumSet
(Item 32),它没有公有的构造函数,只有静态工厂方法。根据枚举类型的大小,静态工厂方法返回两个实现中的一个,枚举类型的分类:如果枚举类型中有六十四个元素或更少,与大多数枚举类型一样,静态工厂返回一个RegularEnumSet
实例,由单个的long
支持;如果枚举类型中有六十五个元素或更多,静态工厂方法返回一个JumboEnumSet
实例,由long[]
支持。
现有的两个实现类对于客户端是不可见的。如果RegularEnumSet
对于较少数量的枚举类型没有提供性能优势,那么在将来的版本中将其移除不会任何影响。同样地,如果新的EnumSet
实现在性能上更有优势,在将来的版本中添加EnumSet
的第三或第四个实现也不会有任何影响。客户端不知道也不关心它们从工厂方法中得到的对象所属的类;它们只关心它是EnumSet
的某个子类。
在编写静态工厂方法所属的类时,静态工厂方法返回的对象所属的类可以不必存在。这种灵活的静态工厂方法形成了服务提供者框架的基础,例如Java数据库链接API(JDBC)。服务提供者框架是一个系统:多个服务提供者实现一个服务,系统为客户端提供服务的多个实现,使客户端与服务实现解耦。
服务提供者框架有三个基本的组件:服务接口,提供者实现;提供者注册API,系统用来注册实现,使客户端能访问它们;服务访问API,客户端用来得到服务实例。服务访问API通常允许但不要求客户端指定一些选择提供者的规则。在没有指定的情况下,API返回一个默认的实现实例。服务访问API是”灵活的静态工厂”,其形成了服务提供者框架的基础。
服务提供者框架的第四个可选组件是服务提供者接口,服务提供者通过实现这个接口来创建服务实现的实例。在没有服务提供者接口的情况下,服务实现通过类名进行注册,通过反射来进行实例化(Item 53)。在JDBC的案例中,Connection
是服务接口,DriverManager.registerDriver
是提供者注册API,DriverManager.getConnection
服务访问API,Driver
是服务提供者接口。
服务提供者框架模式有许多变种。例如,服务访问API通过使用适配器模式[Gamma95, p. 139],能返回比提供者需要的更更丰富的服务接口。下面是服务提供者接口的一个简单实现和默认的提供者:
1 | // Service provider framework sketch |
静态工厂方法的第四个优势是它们降低了创建参数化类型实例的冗长性。遗憾的是,当你调用参数化类的构造函数时,你必须指定类型参数,即使它们在上下文中是非常明显的。这通常需要你紧接着提供两次类型参数:
1 | Map<String, List<String>> m = |
随着类型参数长度和复杂性的增加,这个冗长的说明很快就让人变得很痛苦。但是使用静态工厂的话,编译器可以为你找出类型参数。这被称为类型推导。例如,假设HashMap
由这个静态工厂提供:
1 | public static <K, V> HashMap<K, V> newInstance() { |
你可以将上面冗长的声明用下面简洁的形式去替换:
1 | Map<String, List<String>> m = HashMap.newInstance(); |
某一天,Java语言可能在构造函数调用上也有与方法调用类似的类型推导,但到发行版本1.6为止,它一直没有。
遗憾的是,但到发行版本1.6为止,标准集合实现例如HashMap
没有工厂方法,但你可以把这些方法放到你自己的工具类力。更重要的是,你可以在你自己的参数化类里提供这样的静态工厂。
只提供静态工厂方法的缺点是没有公有或保护构造函数的类不能进行子类化。公有静态工厂返回的非公有类同样如此。例如,不可能子类化集合框架中的这些便利实现类。可以说这是因祸得福,因为它鼓励程序员使用组合来代替继承(Item 16)。
静态工厂方法的第二个缺点是它们不能很容易的与其它静态方法进行区分。它们不能像构造函数那样在API文档中明确标识出来,因此很难弄明白怎样实例化一个提供静态工厂方法代替构造函数的类。Javadoc工具可能某一天会关注静态工厂方法。同时,你可以通过在类中或接口注释中注意静态工厂和遵循通用命名约定来减少这个劣势。下面是静态工厂方法的一些常用命名:
valueOf
— 不严格地说,返回一个与它的参数值相同的一个实例。这种静态工厂是有效的类型转换方法。of
—valueOf
的一种简洁替代方法,通过EnumSet
(Item 32)得到普及。getInstance
— 返回一个通过参数描述的实例,但不能说是相同的值。在单例情况下,getInstance
没有参数并且返回唯一的一个实例。newInstance
— 除了newInstance
保证每个返回的实例都是与其它的实例不同之外,其它的类似于getInstance
,getType
— 类似于getInstance
,当静态工厂方法在不同的类中时使用。Type
表示静态工厂方法返回的对象类型。newType
— 类似于newInstance
,当静态工厂方法在不同的类中时使用。Type
表示静态工厂方法返回的对象类型。
总之,静态工厂方法和公有构造函数都有它们的作用,理解它们的相对优势是值得的。静态工厂经常是更合适的,因此要避免习惯性的提供公有构造函数而不首先考虑静态工厂。
Item 2:当面临很多构造函数参数时,要考虑使用构建器**
静态工厂和构造函数有一个共同的限制:对于大量可选参数它们都不能很好的扩展。考虑这样一种情况:用一个类来表示包装食品上的营养成分标签。这些标签有几个字段是必须的——每份含量、每罐含量(份数)、每份的卡路里,二十个以上的可选字段——总脂肪量、饱和脂肪量、转化脂肪、胆固醇、钠等等。大多数产品中这些可选字段中的仅有几个是非零值。
你应该为这样的一个类写什么样的构造函数或静态工厂?习惯上,程序员使用重叠构造函数模式,在这种模式中只给第一个构造函数提供必要的参数,给第二个构造函数提供一个可选参数,给第三个构造函数提供两个可选参数,以此类推,最后的构造函数具有所有的可选参数。下面是一个实践中的例子。为了简便,只显示了四个可选字段:
1 | //Telescoping constructor pattern - does not scale well! |
当你想创建一个实例时,你可以使用具有最短参数列表的构造函数,最短参数列表包含了所有你想设置的参数:
1 | NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0, 35, 27); |
通常构造函数调用需要许多你不想设置的参数,但无论如何你不得不为它们传值。在这种情况下,我们给fat
传了一个零值。只有六个参数可能还不是那么糟糕,但随着参数数目的增长它很快就会失控。
简而言之,重叠构造函数模式有作用,但是当有许多参数时很难编写客户端代码,更难的是阅读代码。读者会很奇怪所有的这些值是什么意思,必须仔细的计算参数个数才能查明。一长串同类型的参数会引起细微的错误。如果客户端偶然的颠倒了两个这样的参数,编译器不会报错,但程序在运行时会出现错误的行为(Item 40)。
当你面临许多构造函数参数时,第二个替代选择是JavaBeans模式,在这种模式中你要调用无参构造函数来创建对象,然后调用setter
方法为每一个必要参数和每一个有兴趣的可选参数设置值:
1 | //JavaBeans Pattern - allows inconsistency, mandates mutability |
这个模式没有重叠构造函数模式的缺点。即使有点啰嗦,但它很容易创建实例,也很容易阅读写出来的代码:
1 | NutritionFacts cocaCola = new NutritionFacts(); |
遗憾的是,JavaBeans模式自身有着严重缺点。因为构造过程跨越多次调用,JavaBean在构造过程中可能会出现不一致的状态。JavaBean类不能只通过检查构造函数参数的有效性来保证一致性。当一个对象处于一种不一致的状态时,试图使用它可能会引起失败,这个失败很难从包含错误的代码中去掉,因此很难调试。与此相关的一个缺点是JavaBeans模式排除了使一个类不可变的可能性*(Item 15),因此需要程序员付出额外的努力来确保线程安全。
当构造工作完成时,可以通过手动『冰冻』对象并且在冰冻完成之前不允许使用它来弥补这个缺点,但这种方式太笨重了,在实践中很少使用。而且,由于编译器不能保证程序员在使用对象之前调用了冰冻方法,因此它可能在运行时引起错误。
幸运的是,这儿还有第三种替代方法,它结合了重叠构造函数模式的安全性和JavaBeans模式的可读性。它就是构建器模式[Gamma95, p. 97]。它不直接构建需要的对象,客户端调用具有所有参数的构造函数(或静态工厂),得到一个构造器对象。然后客户端在构建器上调用类似于setter的方法来设置每个感兴趣的可选参数。最终,客户端调用无参构建方法来产生一个对象,这个对象是不可变的。构建器是它要构建的类的静态成员类(Item 22)。它在实践中的形式如下:
1 | //Builder Pattern |
注意NutritionFacts
是不可变的,所有参数的默认值都在一个单独的位置。构建器的setter
方法返回的是构建器本身,为的是链式调用。客户端代码如下:
1 | NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8).calories(100).sodium(35).carbohydrate(27).build(); |
客户端代码很容器写,更重要的是很容易读。构建器模式模拟了命名可选参数,就像Ada和Python中的一样。类似于构造函数,构造器可以对它参数加上约束条件。构造器方法可以检查这些约束条件。将参数从构建器拷贝到对象中之后,可以在对象作用域而不是构造器作用域对约束条件进行检查,这是很关键的(Item 39)。如果违反了任何约束条件,构造器方法会抛出IllegalStateException
异常(Item 60)。异常的详细信息会指出违反了哪一个约束条件(Item 63)。
相比于构造函数,构建器的一个小优势在与构建器可以有许多可变参数。构造函数类似于方法,只能有一个可变参数。由于构造器用单独的方法设置每一个参数,因此像你喜欢的那样,它们能有许多可变参数,直到每个setter方法都有一个可变参数。
构建器模式是灵活的。一个构建器可以用来构建多个对象。为了改变对象,构建器参数在创建对象时可以进行改变。构建器能自动填充一些字段,例如每次创建对象时序号自动增加。
设置了参数的构建器形成了一个很好的抽象工厂[Gamma95,p.87]。换句话说,为了使某个方法能为客户端创建一个或多个对象,客户端可以传递这样的一个构建器到这个方法中。为了使这个用法可用,你需要用一个类型来表示构建器。如果你在使用JDK 1.5或之后的版本,只要一个泛型就能满足所有的构建器(Item 26),无论正在构建的是什么类型:
1 | // A builder for objects of type T |
注意我们可以声明NutritionFacts.Builder
类来实现Builder<NutritionFacts>
。
带有构建器实例的方法通常使用绑定的通配符类型来约束构建器的类型参数(Item 28)。例如,构建树的方法通过使用客户端提供的构建器实例来构建每一个结点:
1 | Tree buildTree(Builder<? extends Node> nodeBuilder) { ... } |
Java中传统的抽象工厂实现是类对象,newInstance
方法扮演着build
方法的角色。 这种用法问题重重。newInstance
方法总是尝试调用类的无参构造函数,但无参构造函数可能并不存在。如果类没有访问无参构造函数,你不会收到编译时错误。而客户端代码必须处理运行时的InstantiationException
或IllegalAccessException
异常,这样既不雅观也不方便。newInstance
也会传播无参构造函数抛出的任何异常,即使newInstance
缺少对应的抛出语句块。换句话说,Class.newInstance
打破了编译时的异常检测。上面的Builder
接口弥补了这些缺陷。
构建器模式也有它的缺点。为了创建对象,你必须首先创建它的构建器。虽然创建构建器的代价在实践中可能不是那么明显,但在某些性能优先关键的情况下它可能是一个问题。构建器模式比重叠构造函数模式更啰嗦,因此只有在参数足够多的情况下才去使用它,比如四个或更多。但要记住将来你可能会增加参数。如果你开始使用构造函数或静态工厂,当类发展到参数数目开始失控的情况下,才增加一个构建器,废弃的构造函数或静态工厂就像一个疼痛的拇指,最好是在开始就使用构建器。
总之,当设计的类的构造函数或静态工厂有许多参数时,构建器模式是一个很好的选择,尤其是大多数参数是可选参数的情况下。与传统的重叠构造函数模式相比,使用构建器模式的客户端代码更易读易编写,与JavaBeans模式相比使用构建器模式更安全。
Item 3 用私有构造函数或枚举类型强化单例属性
单例简单来说就是一个类只被实例化一次[Gamma95, p. 127]。通常单例表示一个系统组件在本质上来说是唯一的,例如窗口管理或文件系统。一个类成为单例会使它的客户端测试变得很困难,因为不可能用伪实现来代替单例,除非它实现了一个接口,这个接口作为它的服务类型。
在1.5版本之前,有两种方式来实现单例。它们都是通过保持私有构造函数并输出一个公有静态成员来提供对类唯一实例的访问来实现的。在第一种方法中,公有静态成员被声明为final字段:
1 | // Singleton with public final field |
为了初始化公有静态final变量Elvis.INSTANCE
,私有构造函数只调用一次。公有或保护构造函数的缺失保证了全局唯一性:确切的说一旦Elvis
类初始化,将只有一个Elvis
实例存在——不会多也不会少。客户端不能改变这个情况,但要提醒一点:有特权的客户端可以借用AccessibleObject.setAccessible
方法方法,通过反射机制(Item 53)的调用私有构造函数。如果你需要抵御这种攻击,修改构造函数使它在创建第二个实例时抛出异常。
1 | // Singleton with static factory |
所有Elvis.getInstance
方法的调用都会返回同一个对象实例,并且不会有其它的Elvis
实例被创建(提醒同上)。
公有变量方法的主要优势在于更清晰的声明这个类是一个单例类:公有静态变量是final的,因此它总是包含同一个对象的引用。公有变量方法没有任何性能优势:现代Java虚拟机(JVM)的大多数实现都是将静态工厂方法当做内联函数来调用。
为了使上面方法实现的单例类可序列化(第11章),仅仅在它的声明中实现Serializable
接口是不够的。为了保证单例性,你必须将所有的实例变量声明为transient
并提供一个readResolve
方法(Item 77)。否则,每次一个序列化的实例在反序列化时将会创建一个新的实例,在我们的例子中,会看到一个假的Elvis
。为了防止这种情况发生,要在Elvis
类中添加readResolve
方法:
1 | // readResolve method to preserve singleton property |
在1.5版本中,有第三种实现单例的方法。简单声明一个只有一个元素的枚举类型:
1 | // Enum singleton - the preferred approach |
这个方法除了它更简洁之外,它在功能上等价于公有变量方法,免费提供了序列化机制,并且强有力的保证了不会被多次实例化,即使是在面临复杂的序列化或反射攻击时。虽然这个方法仍没有被广泛采用,但单元素的枚举类型是实现单例的最好方式。
Item 4: 用私有构造函数强制不能实例化
有时你会想写一个只包含一组静态方法和静态变量的类。这种类的名声很不好,因为有些人滥用它们来避免思考如何面向对象,但它们确实是有用的。它们可以用来以java.lang.Math
或java.util.Arrays
的方式来组织与基本类型或数组相关的方法。它们也可以用来以java.util.Collections
的方式来组织实现特定接口对象的静态方法,包括工厂方法(Item 1)。最后,它们可以用来组织一个fianl类的方法,从而代替扩展这个类。
这种工具类被设计成不能实例化:它的实例是没有意义的。然而,在缺少显式构造函数的情况下,编译器会提供一个公有的无参构造默认函数。对用户而言,这个构造函数与其它的构造函数没有任何差别。在发布的APIs中看到无意义的可实例化类是很罕见的。
企图通过声明一个类为抽象类来强制类不能被实例化是行不通的。这个类可以被子类化,子类可以被实例化。而且,它会使用户误认为这个类是为继承而设计的(Item 17)。然而有一些简单的习惯用法可以确保类不能被实例化。如果一个类没有显式的构造函数,会产生默认的构造函数,因此,一个含有私有构造函数的类不能被实例化:
1 | // Noninstantiable utility class |
因为显式构造函数是私有的,因此类的外部不能访问构造函数。AssertionError
不是必须的,但它可以避免类内部无意的调用构造函数。这种习惯用法有点违背直觉,似乎构造函数的提供就是为了它不能被调用一样。因此明智的做法是在类中加上注释,像上面的例子一样。
这种习惯用法的一个副作用就是阻止了类的子类化。子类的所有的构造函数必须调用父类的构造函数,无论是显式的或隐式的,但这种情况下子类不能调用父类构造函数。
Item 5: 避免创建不必要的对象
每次需要一个对象时,与创建一个新的功能相同的对象相比,复用一个对象经常是合适的。复用更快更流行。如果一个对象是不变的,那它总是可以复用。(Item 15)
作为一个不该做什么的极端例子,请看下面这种情况:
1 | String s = new String("stringette"); // DON'T DO THIS! |
这条语句每次执行时都会创建一个新的String
实例,这些对象的创建都是没必要的。String
构造函数的参数"stringette"
本身就是一个String
实例,在功能上与构造函数创建的所有对象都是等价的。如果这种用法出现在一个循环或一个频繁调用的方法中,会创建出成千上万的不必要的实例。
改进版本如下:
1 | String s = "stringette"; |
这个版本使用单个的String
实例,而不是每次执行时创建一个新实例。此外,它保证了运行在虚拟中包含同样字符串的任何其它代码都可以复用这个对象[JLS, 3.10.5]。
除了复用不可变对象之外,如果你知道可变对象不会被修改,你也可以复用可变对象。下面是一个比较微妙,更为常见反面例子。它包含可变的Date
对象,这些Date
对象一旦计算出来就不再修改。这个类对人进行了建模,其中有一个isBabyBoomer
方法用来区分这个人是否是一个“baby boomer(生育高峰时的小孩)”,换句话说就是判断这个人是否出生在1946年到1964年之间:
1 | public class Person { |
每次调用时,isBabyBoomer
方法都会创建一个Calendar
实例,一个TimeZone
实例和两个Date
实例,这是不必要的。下面的版本用静态初始化避免了这种低效率的问题:
1 | class Person { |
Person
类的改进版本只在初始化时创建Calendar
,TimeZone
和Date
实例一次,而不是每次调用isBabyBoomer
方法都创建它们。如果isBabyBoomer
方法被频繁调用的话,这样做在性能上会有很大提升。在我的机器上,最初的版本一千万次调用要花费32,000毫秒,而改进版本只花了130毫秒,比最初版本快了大约250倍。不仅性能改善了,代码也更清晰了。将boomStart
和boomEnd
从局部变量变为static final
字段,很明显是将它们看作常量,代码也更容易理解。从整体收益来看,这种优化的节约并不总是这么戏剧性的,因为Calendar
实例创建的代价是非常昂贵的。
如果初始化Person
类的改进版本,但从不调用它的isBabyBoomer
方法,BOOM_START
和BOOM_END
字段的初始化就是不必要的。可以通过延迟初始化(当需要时再初始化)这些字段(Item 71)来消除这些不必要的初始化,当第一次调用isBabyBoomer
方法时再进行初始化,但不推荐这样做。延迟初始化是常有的事,它的实现是非常复杂的,除了我们已有的性能提升之外,延迟初始化不可能引起明显的性能提升(Item 55)。
在本条目前面的例子中,很明显问题中的对象可以复用,因为它们在初始化之后没有被修改。但在其它的情况下它就不那么明显了。考虑一个适配器的情况[Gamma95, p. 139],也称之为视图。适配器是代理支持对象的对象,为支持对象提供了一个可替代的接口。由于适配器除了它的支持对象之外没有别的状态,因此没必要创建多个给定对象的适配器实例。
在JDK 1.5中有一种新的方式来创建不必要对象。它被称为自动装箱,它允许程序员混合使用基本类型和它们的包装类型,JDK会在需要时自动装箱和拆箱,自动装箱虽然模糊但不能去除基本类型和包装类之间的区别。它们在语义上有稍微的不同,但不是轻微的性能差异(Item 49)。看一下下面的程序,计算所有正数int
值的总和。为了计算这个,程序必须使用long
类型,因为int
不能容纳所有正int
值的和:
1 | // Hideously slow program! Can you spot the object creation? |
这个程序算出了正确答案,但由于一个字符的错误,它运行的更慢一些。变量sum
声明为Long
而不是long
,这意味着程序构建了大约2^31不必要的Long
实例(基本上每次long i
加到Long sum
上都要创建一个)。将sum
从Long
声明为long
之后,在我机器上运行时间从43秒降到了6.8秒。结论很明显:使用基本类型优先于包装类,当心无意的自动装箱。
不该将本条目误解成暗示创建对象是昂贵的,应该避免创建对象。恰恰相反,创建和回收构造函数做很少显式工作的小对象是非常廉价的,尤其是在现代的JVM实现上。创建额外的对象来增强程序的清晰性,简洁性,或能力通常是一件好事。
相反的,通过维护你自己的对象池来避免创建对象是一个坏主意,除非对象池中的对象是极度重量级的。真正证明对象池的对象经典例子是数据库连接。建立连接的代价是非常大的,因此复用这些对象是很有意义的。数据库许可可能也限制你使用固定数目的连接。但是,通常来说维护你自己的对象池会使你的代码很乱,增加内存占用,而且损害性能。现代JVM实现有高度优化的垃圾回收机制,维护轻量级对象很容易比对象池做的更好。
与本条目对应的是Item 39 保护性拷贝。Item 5 声称,『不要创建一个新的对象,当你应该复用一个现有的对象时』,而Item 39 声称,『不要重用一个现有的对象,当你应该创建一个新的对象时』。注意,当保护性拷贝时复用一个对象的代价要远大于创建一个不必要的重复对象的代价。当需要时没有创建一个保护性拷贝可能导致潜在的错误和安全漏洞;创建不必要的对象只会影响程序风格及性能。
Item 6: 消除废弃的对象引用
当你从一个手动管理内存的语言(例如C或C++)转到一个具有垃圾回收机制的语言时,作为一个程序员你的工作会更容易,当你使用完对象时,它们会被自动回收。当你第一个经历它时,它简直不可思议。它很容易给你留下一个你不需要考虑内存管理的印象,但事实并非如此。
考虑下面一种简单的栈实现的情况:
1 | // Can you spot the "memory leak"? |
这个程序没有明显的错误(但请看Item 26的泛型版本)。你可以对它进行全面测试,它能出色的通过每一次测试,但这儿有一个潜在的问题。不严格的说,这个程序有一个『内存泄露』问题,由于垃圾回收活动的增加或内存占用的增加,性能下降的情况会逐渐表现出来。在极端的情况下,这种内存泄露可能引起磁盘分页,甚至会引起程序失败(OutOfMemoryError
),但这种失败是相对稀少的。
内存泄露在哪呢?如果栈先增长后收缩,出栈的对象将不能作为垃圾被收回,即使使用栈的程序不再引用它们。这是因为栈维护着这些对象的废弃引用。废弃引用是永远不会再解引用的引用。在这种情况下,元素数组活跃部分之外的其它引用都将被废弃。活跃部分包含了那些索引小于size
的元素。
内存泄露在垃圾回收语言是隐蔽的(更合适的称呼是无意识对象保持)。如果一个对象引用被无意保留,不仅这个对象不能被垃圾回收处理,而且这个对象引用的其它对象也不能被垃圾回收处理,以此类推。即使只无意保留了几个对象的引用,但可能阻止了垃圾回收机制回收许多其它的对象,在性能上会有很大的潜在影响
这类问题的修正很简单:一旦对象引用过期,就清空这些引用。在我们的Stack
类例子中,只要某一项从栈中取出,它的引用就过时了。pop
方法的修正版本如下:
1 | public Object pop() { |
清空废弃引用的一个额外收益是,如果它们接下来被误解引用,程序会立刻报出NullPointerException
,而不是静静地做错误的事情。对于尽可能快的检测程序错误,它总是有益的。
当程序员第一次被这个问题困扰时,他们可能是过分小心了,程序一旦完成了对象的使用,就清空每一个对象的引用。这既没必要也不可取,因此它会将程序不必要的弄乱。清空对象引用应该是例外情况而不是正常的行为。消除废弃引用的最好方式是让包含引用的变量结束其作用域。如果你在最紧凑的作用域范围内定义每个变量,这会很自然的发生。
你应该什么时候清空一个引用?Stack
类的哪一个方面让它容易受到内存泄露影响?简单的说,它自己管理自己的内存。存储池包含了元素数组中的元素(对象引用单元,不是对象本身)。数组活跃部分的元素(前面定义的)被分配,数组中其余的元素是自由的。垃圾回收器不知道这种情况;对于垃圾回收器而言,元素数组中的所有对象引用都同等有效。只有程序员知道数组中非活跃部分是不重要的。程序员通过手动清空数组元素中不活跃的部分,可以有效的告诉垃圾回收器这个事实。
一般来说,只要一个类自己管理自己的内存,程序员就应该警惕内存泄露。无论什么时候释放一个元素,这个元素包含的对象引用都应该被清空。
另一个常见的内存泄露来源是缓存。一旦你把一个对象引用放入缓存,很容易忘了它在缓存中,在用完之后很长一段时间仍把它放在缓存中。这个问题有几种解决方案。如果你很幸运的要实现一个对于输入项的缓存,只要缓存外部有输入项的键的引用,它就是相对确定的,可以用一个WeakHashMap
来表示缓存;在输入项废弃之后,它们会被自动移除。记住,只有缓存输入项的生命周期由输入项键的外部引用决定,不是由输入项值的外部引用决定时,WeakHashMap
才有用的。
第三个常见的内存泄露来源是监听器和其它的回调函数。如果你实现一个API,它的客户端注册了回调函数但没有显式的注销它们,除非你采取一些动作,否则它们将累积。确保回调函数可以迅速被垃圾回收的最好方式是为存储它们的弱引用,例如,只将它们保存为WeakHashMap
的键。
因为通常内存泄露没有明白的失败来揭露它们,它们可能在系统中存在许多年。通常只有通过小心的代码检查或通过调试工具(通常被称为堆分析器)的帮助才能发现它们。因此,在它们发生和阻止它们发生之前,就学习预测这种问题是很有必要的。
Item 7: 避免使用finalizers(终结方法,Java模拟C++的析构函数)
终结方法通常是不可预测的,经常是危险的,一般来说是没必要的。使用它们会引起不稳定的行为,性能变低,可移植性问题等。终结方法有一些有效的使用,这个在本条目的后面会讲到,但根据经验,你应该避免使用终结方法。
C++程序员被警告说不要去想像Java中模拟C++析构函数那样的终结方法。在C++中,析构函数是一种正常回收对象资源的方式,是构造函数的必要对应。在Java中,当对象不可访问时,垃圾回收器会回收对象的相关资源,不需要程序员进行专门的工作。C++析构函数也用来回收其它的非内存资源。在Java中,try-finally
块用来完成这样的功能。
终结方法的一个缺点是不能保证它们及时的执行[JLS,12.6]。从对象变得不可访问开始到它的终结方法被执行结束,这中间的时间可以任意长。这意味着你不应该在终结方法中做任何时间为关键的事情。例如,依赖终结方法来关闭文件是一个严重的错误,因为开放的文件描述符是一种有限的资源。如果许多文件都是打开状态,由于JVM执行终结方法时是迟缓的,因此程序可能失败,因为它不能再打开文件。
尽快执行终结方法是垃圾回收算法的主要功能,在不同的JVM实现中变化很大。依赖终结方法执行及时性的程序同样变化很大。一个程序在测试它的JVM上运行非常完美,但在你最重要客户支持的JVM上它却糟糕地运行失败了,这是完全有可能的。
迟缓终结不仅仅是一个理论问题。在很少的情况下,为一个类提供终结方法可能会随意地延迟它实例的回收。有个同事调试一个长期运行的GUI应用,程序莫名其妙的死掉了,抛出了OutOfMemoryError
错误。分析表明在程序死亡时,应用中的终结方法队列中有成千上万的图形对象在等待被终结并回收。遗憾的是,终结方法线程的运行优先级要低于另一个应用线程,因此在另一个应用线程中的对象变得可以被终结时,它们不能被终结。语言规范不能保证哪一个线程来执行终结方法,因此没有轻便的方式来阻止这种问题的发生,除非避免使用终结方法。
不仅语言规范不能保证终结方法及时的执行;而且也不能保证终结方法得到执行。这完全有可能,甚至有可能一个程序终止时,一些不能访问的对象的终结方法都没有执行。结论就是:你从不该依赖终结方法来更新重要的持续状态。例如,依赖一个终结方法来释放一个共享资源,例如数据库,的持续锁,很容易引起整个分布式系统当掉。
不要被System.gc
和System.runFinalization
方法诱惑。它们可能会增加终结方法得到执行的几率,但它们不能保证它。能保证终结方法执行的唯一方法是System.runFinalizersOnExit
以及它臭名昭著的孪生兄弟Runtime.runFinalizersOnExit
。这些方法都有致命的缺陷并且已经被废弃了[ThreadStop]。
以防你还不相信终结方法应该被避免,这儿有另一个情况值得思考:如果在终结方法执行期间抛出了一个无法捕获的异常,这个异常被忽略了,对象的终结方法终止了[JLS,12.6]。不能捕获的异常可能会使对象处于崩溃状态。如果另一个线程试图使用这样一个崩溃的对象,任何不确定性的行为都有可能发送。通常,一个未被捕获的异常会终止线程并打印栈轨迹,但如果它发生在一个终结方法中,将不会打印出警告。
哦,还有一件事:使用终结方法会有严重的性能问题。在我的机器上,创建并销毁一个简单对象大约是5.6纳秒。添加一个终结方法会将这个时间增加到2400纳秒。换句话说,创建一个对象并用终结方法销毁对象比正常情况下大约慢了430倍。
因此当一个类的对象封装的资源需要结束时,你应该用什么来代替一个类的终结方法?例如文件或线程?提供一个显式的结束方法,当类的实例不再需要时,要求类的客户端在每个实例上都调用这个方法。一个值得提及的细节是,实例必须跟踪它是否已经被终结:显式的终结方法必须记录在一个私有字段中,这个字段表明对象不再有效,如果其它方法再对象终结后调用对象,其它方法必须检查这个字段并抛出IllegalStateException
。
显式结束方法的典型例子是InputStream
,OutputStream
和java.sql.Connection
的关闭方法。另一个例子是java.util.Timer
的cancel
方法,它会进行必要的状态检查并一起线程相关的Timer
实例平稳的结束它自己。java.awt
的例子包括Graphics.dispose
和Window.dispose
。这些方法经常被忽视,可以预料会引起可怕的性能后果。一个相关的方法是Image.flush
,它会释放所有Image
实例相关的资源,但会将实例保持在仍可用的状态,如果必要的时候重新分配资源。
显式结束方法通过与try-finally
结构结合来确保终结。在finally
语句块的内部调用显式的结束方法来确保它得到执行,即使对象使用时抛出了一个异常:
1 | // try-finally block guarantees execution of termination method |
那终结方法有什么好处呢?有两种可能的合法应用。一个是作为『安全网』,以防对象拥有者忘记调用它的显式结束方法。但这不能保证终结方法得到及时的调用,当客户端调用显式结束方法失败时,在那种情况下(希望很少),后面释放资源总比不释放资源要好。但终结方法如果发现资源仍没有被释放,它应该输出一个警告,因为这意味着客户端代码存在一个BUG,它应该被修正。如果你正在考虑写这样一个安全网终结方法,要仔细思考这种额外的保护是否值得额外的代价。
作为显式结束方法模式引用的四个例子(FileInputStream
,FileOutputStream
,Timer
和Connection
)都有终结方法作为安全网以防它们的结束方法没有被调用。遗憾的是这些终结方法不输出警告。这种警告通常在API发布后不能进行添加,因为它会损坏现有的客户端。
终结方法的第二个合法使用是关于对象的本地对等体。本地对等体是一个本地对象,普通对象通过本地方法委托给本地对象。由于本地对等体不是一个正常的对象,当它的Java对等体回收时,垃圾回收器不知道并且不能回收它。假设本地对等体不拥有重要的资源,终结方法是执行这个任务的合适工具。如果本地对等体拥有必须及时终止的资源,这个类应该有一个显式的结束方法,如上所述。结束方法应该用来释放重要资源。结束方法可以是一个本地方法或它可以调用一个本地方法。
很重要的一点就是要注意『终结方法链』是不能自动执行的。如果一个类(不是Object
)有一个终结方法,一个子类覆写了它,子类终结方法必须手动调用父类终结方法。你应该try
块内终止这个子类并在对应的finally
块调用父类终结方法。这保证了父类终结方法得到了执行,即使子类终结方法抛出异常,反之亦然。下面是它的一个例子、注意这个例子使用了Override
注解(@Override
),在release 1.5版本中添加。现在你可以忽略Override
注解,或看Item 36弄明白它是什么意思:
1 |
|
1 | // Finalizer Guardian idiom |
注意公有类Foo
没有终结方法(除非它从Object
继承一个无关紧要的),因此子类的终结方法是否调用super.finalize
是不重要的。每一个含有终结方法的非终结公有类都应该考虑这个技术。
总结:不要使用终结方法,除非是用作安全网或用来终止一个非重要的本地资源。在那些你使用终结方法的稀少实例中,记住调用super.finalize
。如果你使用终结方法作为安全网,记住在终结方法中输出非法用法。最后,如果你需要将终结方法关联到一个公有的,非终结类,考虑使用终结方法守护者,即使子类终结方法调用super.finalize
失败,也会进行终结。
CHAPTER3 所有对象的共通方法
虽然Object
是一个具体的类,但设计它的主要目的是为了扩展。它的所有非final
方法(equals
,hashCode
,toString
,clone
和finalize
)都有明确的通用约定,因为设计它们的目的是为了重写。任何类都应该遵循通用约定重写这些方法;不这样做的话,依赖这些约定的其它类(例如HashMap
和HashSet
)将无法结合这个类正确运行。
会告本章诉你什么时候,怎样重写这些非final的Object
方法。本章会忽略finalize
方法,因为它在Item 7中已经讨论过了。虽然不是一个Object
方法,但是这章仍会讨论Comparable.compareTo
,因为它有一个类似的特性。
Item 8:当重写equals时要遵循通用约定
重写equals
方法看似简单,但许多方式都会导致错误,结果是非常可怕的。避免这些问题的最简单方式是不要重写equals
方法,在这种情况下类的每个实例只等价于它本身。如果符合以下任何条件,这样做就是正确的:
类的每个实例本质上都是唯一的。对于表示活动实体而不是表示值的类确实如此,例如
Thread
。对于这些类,Object
提供的equals
实现具有完全正确的行为。不关心类是否提供“逻辑等价”的测试。例如,
java.util.Random
可以重写equals
方法来检查两个Random
实例是否会产生相同的随机数序列,但设计者认为客户不需要或者不想要这个功能。在这种情况下,从Object
继承的equals
实现就足够了。超类已经重写了
equals
,超类的行为对于子类是合适的。例如,大多数Set
实现从AbstractSet
继承了equals
实现,List
实现从AbstractList
继承了equals
实现,Map
实现从AbstractMap
继承了equals
实现。类是私有的或包私有的,可以确定它的
equals
方法从不会被调用。可以说,在这些情况下equals
方法应该重写,以防它被偶然调用:
1 | public boolean equals(Object o) { |
什么时候重写Object.equals
方法是合适的?如果类具有逻辑等的概念,不同于对象同一性,并且超类没有重写equals
方法来实现要求的行为,这时候就需要重写equals
方法。这种情况通常是对值类而言的。值类仅仅是表示值的类,例如Integer
或Date
。程序员用equals
方法比较值对象的引用,期望找出它们是否是逻辑等价的,而不管它们是否是同一对象。重写equals
方法不仅满足了程序员的期望;它也能使实例作为映射表的主键或者集合的元素,使它们表现出可预期的行为。
有一种不需要重写equals
方法的值类,它通过实例控制(Item 1)来确保每个值至多存在一个对象。枚举类型(Item 30)就是这种类。对于这种类而言,逻辑等价等同与对象同一性,Object
的equals
方法在功能上就如同逻辑等价方法。
当你重写equals
方法时,你必须遵循通用约定。下面是约定内容,从Object
规范[JavaSE6]中拷贝的:
equals
实现了一种等价关系。它是:
自反性:对于任何非空引用值
x
,x.equals(x)
必须返回true
。对称性:对于任何非空引用值
x
和y
,x.equals(y)
必须返回true
当且仅当y.equals(x)
返回true
。传递性:对于任何非空引用值,
x
,y
,z
,如果x.equals(y)
返回true
并且y.equals(z)
返回true
,则x.equals(z)
必须返回true
。一致性:对于任何非空引用值
x
和y
,x.equals(y)
的多次调用一致返回true
或一致返回false
,假设对象进行equals
比较时没有修改任何信息。对于非空引用值
x
,x.equals(null)
必须返回false
。
除非你擅长数学,否则这可能看起来有点可怕,但不要忽视它!如果你违反了它,你可能会发现你的程序表现不正常或程序崩溃,并且很难确定失败的来源。用John Donne的话来说,没有类是孤立的。一个类的实例频繁传递给另一个类。许多类,包括所有的集合类,都依赖于传递给它们的对象遵循equals
约定。
现在你已经意识到了违反了equals
约定的危险,让我们详细回顾一下这个约定。好消息是实际上这个约定并不复杂,尽管从表面上来看不是这样。一旦你理解了它,遵循它并不难。让我们依次检查这五个要求:
自反性——第一个要求仅仅是说一个对象必须等价于它本身。很难想象会无意的违反这个要求。如果你违反了它并将你的类实例添加到一个集合中,集合的contains
方法可能会说这个集合中不包含你刚刚添加的实例。
对称性——第二个要求是说任何两个对象必须对它们是否相等达成一致。不像第一个要求,不难想象会无意的违反这个要求。例如,考虑下面的类,它实现了大小写敏感的字符串。字符串保存在toString
中,但在比较时被忽略了:
1 | // Broken - violates symmetry! |
这个类中,equals
方法的意图很好,单纯的想要与普通的字符串进行互操作。假设我们有一个区分大小写的字符串和一个普通的字符串:
1 | CaseInsensitiveString cis = new CaseInsensitiveString("Polish"); |
正如预料的那样,cis.equals(s)
返回true
。问题是虽然CaseInsensitiveString
中的equals
知道普通的字符串,但是String
中的equals
方法不注意不区分大小写的字符串。因此s.equals(cis)
返回false
,这明显违反了对称性。假设你将一个不区分大小写的字符串放到一个集合中:
1 | List<CaseInsensitiveString> list = new ArrayList<CaseInsensitiveString>(); |
这时list.contains(s)
会返回什么?谁知道呢?在Sun当前的实现中,它碰巧会返回false
,但那仅是一种实现方案。在另一种实现中,它也可能很容易的返回true
或抛出一个运行时异常。一旦你违反了equals
约定,当面对你的对象时,你根本不指定其它的对象行为会怎样。
为了消除这个问题,只要从equals
方法中移除与String
进行交互的,考虑不周的尝试即可。一旦你这样做了,你可以重构这个方法给它一个返回即可:
1 |
|
传递性——equals
约定的第三个要求是说如果一个对象等价于第二个对象,而第二个对象等价于第三个对象,则第一个对象等价于第三个对象。同样的,不难想象会无意中违反这个要求。考虑这样一种情况,子类添加一个新的值组件到它的超类中。换句话说,子类添加的信息会影响equals
比较。以一个简单的不可变的二维整数点类作为开始:
1 | public class Point { |
假设你想扩展这个类,给点添加颜色的概念:
1 | public class ColorPoint extends Point { |
equals
方法应该看起来是怎样的?如果一点也不修改,直接从Point
继承equals
方法,在进行equals
比较时颜色信息会被忽略。虽然这没有违反equals
约定,但很明显这是不可接受的。假设你写了一个equals
方法,只有在它的参数是另一个有色点,且它们具有相同的位置和颜色时才返回true
:
1 | // Broken - violates symmetry! |
这个方法的问题在于:当你比较一个普通点和一个有色点或相反的情况时,你可能会得到不同的结果。前者的比较忽略了颜色,而后者总是返回false
,因为参数类型不正确。为了使这个更具体一点,我们创建一个普通点和一个有色点:
1 | Point p = new Point(1, 2); |
p.equals(cp)
返回true
,而cp.equals(p)
返回false
。你可能想让ColorPoint.equals
进行比较混合比较时忽略颜色来修正这个问题:
1 | // Broken - violates transitivity! |
这个方法提供了对称性,但违反了传递性:
1 | ColorPoint p1 = new ColorPoint(1, 2, Color.RED); |
现在p1.equals(p2)
和p2.equals(p3)
返回true
,而p1.equals(p3)
返回false
,很明显这违反了传递性。前两个比较忽略了颜色,而第三个比较考虑了颜色。
因此解决方案是什么?事实证明:在面向对象语言中,等价关系问题是一个基本的问题。无法在扩展一个实例化的类并添加值组件的同时,还保留equals
约定,除非你愿意放弃面向对象抽象的优势。
你可能听说过你可以在equals
方法中通过使用getClass
测试代替instanceof
测试,从而在扩展一个可实例化的类并添加值组件的同时,保留equals
约定:
1 | // Broken - violates Liskov substitution principle (page 40) |
当且仅当它们具有相同的实现类时,上面的代码在比较对象时才会有效。虽然这不是很糟糕,但结果是不可接受的。
假设我们想写一个方法来判断一个整数点是否在单位圆上。下面是一种写法:
1 | // Initialize UnitCircle to contain all Points on the unit circle private static final Set<Point> unitCircle; |
虽然这可能不是实现这个功能的最快方式,但它确实有效。但假设你以某种不添加值组件的方式扩展了Point
,例如通过它的构造函数来追踪创建了多少实例:
1 | public class CounterPoint extends Point { |
里氏替换原则认为,一个类型的任何重要属性也适用于它的子类型,因此该类型编写的任何方法在它的子类型中也都应该工作良好[Liskov87]。但假设我们给onUnitCircle
传递了一个CounterPoint
实例。如果Point
类使用了基于getClass
的equals
方法,onUnitCircle
将会返回false
,无论CounterPoint
实例的x
值和y
值是多少。这是因为集合,例如onUnitCircle
方法中的HashSet
,使用equals
方法来测试是否包含元素,没有CounterPoint
实例等于Point
。然而,如果你在Point
上使用合适的基于instanceof
的equals
方法,当面对CounterPoint
时,同样的onUnitCircle
方法会工作的很好。
尽管没有令人满意的方式来扩展一个可实例化的类并添加值组件,但有一个很好的解决方案。遵循Item 16 “Favor composition over inheritance”的建议,不再让ColorPoint
继承Point
,而是通过在ColorPoint
中添加一个私有的Point
字段和一个公有的视图方法(Item 5),此方法返回一个与有色点具有相同位置的普通点:
1 | // Adds a value component without violating the equals contract |
在Java平台库中有一些类扩展了一个可实例化的类并添加了一个值组件。例如,java.sql.Timestamp
扩展了java.util.Date
并添加了一个nanoseconds
字段。Timestamp
的equals
实现确实违反了对称性,如果Timestamp
和Date
用在同一个集合中或混杂在一起,会引起不稳定的行为。Timestamp
类有一个免责声明,警告程序员不要混合日期和时间戳。虽然只要你将它们分开就不会有麻烦,但是没有任何东西阻止你混合它们,而且产生的错误很难调试。Timestamp
类的这个行为是一个错误,不应该进行模仿。
注意,你可以添加值组件到抽象类的子类而且不会违反equals
约定。对于遵循Item 20 “Prefer class hierarchies to tagged classes”的建议而得到这种类层次来说,这是非常重要的。例如,你可以有一个没有值组件的抽象类Shape
,子类Circle
添加了radius
字段,子类Rectangle
添加了length
和width
字段。只要不能直接创建一个超类实例,上面的种种问题就不会发生。
一致性——equals
约定的第四个要求是说如果两个对象相等,它们必须一致相等,除非其中一个(或二者)被修改了。换句话说,可变对象在不同的时间可以等于不同的对象而不可变对象不能。当你写了一个类,仔细想想它是否应该是不可变的(Item 15)。如果你推断它应该是不可变的,那么要确保你的equals
方法满足这样的约束条件:相等的对象永远相等,不等的对象永远不等。
无论一个类是否是不可变的,都不要写一个依赖于不可靠资源的equals
方法。如果你违反了这个禁令,要满足一致性要求是非常困难的。例如,java.net.URL
的equals
方法依赖于对关联URL主机的IP地址的比较。将主机名转换成IP地址可能需要访问网络,随时间推移它不能保证取得相同的结果。这可能会导致URL equals
方法违反equals
约定并在实践中产生问题。(很遗憾,由于兼容性问题,这一行为不能被修改。)除了极少数例外,equals
方法应该对常驻内存对象进行确定性计算。
“非空性”——最后的要求由于没有名字我称之为“非空性”,这个要求是说所有的对象都不等于null
。虽然很难想象调用o.equals(null)
会偶然的返回true
,但不难想象会意外抛出NullPointerException
的情况。通用约定不允许出现这种情况。许多类的equals
方法为了防止出现这种情况都进行对null
的显式测试:
1 |
|
这个测试是没必要的。为了平等测试其参数,为了调用它的访问器或访问其字段,equals
方法首先必须将它的参数转换成合适的类型。在进行转换之前,equals
方法必须使用instanceof
操作符来检查它的参数是否是正确的类型:
1 |
|
如果缺少类型检查,equals
方法传入了一个错误类型的参数,equals
方法会抛出ClassCastException
,这违反了equals
约定。但当指定instanceof
时,如果它的第一个操作数为null
,无论它的第二个操作数是什么类型,它都会返回false
[JLS, 15.20.2]。所以如果传入null
类型检查将会返回false
,因此你不必进行单独的null
检查。
将这些要求结合在一起,得出了下面的编写高质量equals
方法的流程:
使用
==
操作符来检查参数是否是这个对象的一个引用,。如果是,返回true
。这只是一个性能优化,如果比较的代价有可能很昂贵,这样做是值得的。使用
instanceof
操作符来检查参数类型是否正确。如果不正确,返回false
。通常,正确的类型是指equals
方法所在的那个类。有时候,它是这个类实现的一些接口。如果一个类实现了一个接口,这个接口提炼了equals
约定来允许比较那些实现了这个接口类,那么就使用接口。集合接口例如Set
,List
,Map
和Map.Entry
都有这个属性。将参数转换成正确的类型。由于转换测试已经被
instanceof
在之前做了,因此它保证能成功。对于类中的每一个“有效”字段,检查参数的这个字段是否匹配这个对象的对应字段。如果所有的这些测试都成功了,返回
true
;否则返回false
。如果第二步中的类型是一个接口,你必须通过接口方法访问参数的字段;如果类型是一个类,你可能要直接访问字段,依赖于它们的可访问性。
对于基本类型,如果不是float
或double
,使用==
操作符进行比较;对于对象引用字段,递归地调用equals
方法;对于float
自动,使用Float.compare
方法;对于double
字段,使用Double.compare
。float
和double
字段的特别对待是有必要的,因为存在Float.NaN
,-0.0f
和类似的double
常量;更多细节请看Float.equals
。对于数组字段,对每个元素应用这些指导。如果数组中的每个元素都是有意义的,你可以使用1.5版本中添加的Arrays.equals
方法。
某些对象引用字段可能合理的包含null
。为了避免产生NullPointerException
的可能性,使用下面的习惯用法来比较这些字段:
1 | (field == null ? o.field == null : field.equals(o.field)) |
如果field
和o.field
经常是等价的,使用下面的可替代方式可能会更快:
1 | (field == o.field || (field != null && field.equals(o.field))) |
对于某些类而言,例如上面的CaseInsensitiveString
,字段比较比简单的相等性检测更复杂。如果是这种情况,你可能想存储这个字段的标准形式,因此equals
方法可以在这些标准形式上进行低开销的精确比较,而不是更高代码的非精确比较。这种技术最适合不可变类(Item 15);如果对象可以改变,你必须保持最新的标准形式。
equals
方法的性能可能会受到字段比较顺序的影响。为了最佳性能,你首先应该比较那些更可能不同,比较代价更小的字段,或者理想情况下二者兼具的字段。你不能比较那些不属于对象逻辑状态一部分的字段,例如同步操作中的Lock
字段。你也不需要比较冗余的字段,它们能从“有意义字段”中计算出来,但这样做可能会改善equals
方法的性能。如果冗余字段相当于整个对象的概要描述,比较这个字段,如果失败的话会节省你比较真正数据的开销。例如,假设你有一个Polygon
类,并且你缓存这个区域。如果两个多边形有不同的面积,你就不需要比较它们的边和顶点。
- 当你完成了
equals
方法的编写时,问你自己三个问题:它是否是对称的?是否是可传递的?是否是一致的?并且不要只问你自己;编写单元测试来检查是否拥有这些属性!如果没有这些属性,弄清楚为什么没有,对应的修改equals
方法。当然你的equals
方法也必须满足其它两个属性(自反性和“非空性”),但这两个属性通常会自动满足。
根据上述规则构建的equals
方法具体例子请看Item 9的PhoneNumber.equals`。下面是一些最后的警告:
当你重写
equals
时,总是重写hashCode
方法(Item9)。不要将
equals
声明中的Object
对象替换为其它对象。对于程序员来讲,写一个equals
方法看起来像下面的一样是不常见的,并且花费了好几个小时都不明白它为什么不能正确工作:
1 | public boolean equals(MyClass o) { |
正如本条目阐述的那样,@Override
注解的一致使用会阻止你犯这个错误(Item 36)。这个equals
方法不能编译并且错误信息会确切告诉你错误是什么。
1 |
|
Item 9: 重写equals
时必须重写hashCode
一个常见的错误来源是没有重写hashCode
方。在每个重写equals
方法的类中,你必须重写hashCode
方法。不这样做会违反Object.hashCode
的通用约定,这会使你的类不能在功能上与所有基于哈希的集合进行恰当的结合,包括HashMap
,HashSet
和Hashtable
。
下面是这些约定,从Object
规范中拷贝的[JavaSE6]:
假设同一个对象在进行
equals
比较时没有修改信息,那么在一个应用执行期间,无论什么时候对同一个对象调用多次hashCode
方法,它的hashCode
方法都必须返回一个一致的整数。这个整数在应用多次执行期间不必保持一致。如果两个对象根据
equals
(Object
)方法是相等的,那么调用每一个对象的hashCode
方法必须产生同样的整数结果。如果两个对象根据
equals
(Object
)方法不相等,不要求调用每一个对象的hashCode
方法必须产生同样的整数结果。然而,程序员应该意识到对于不等的对象产生不同的整数结果可能改善哈希表的性能。
当不重写hashCode
时,违反的第二条是关键约定:相等对象必须具有相等的哈希值。两个不同的对象根据类的equals
方法可能在逻辑上是相等的,但对于Object
的hashCode
方法,它们是两个对象,没有共同的东西,因此Object
的hashCode
方法返回两个看似随机的数字来代替约定要求的相等数字。
1 | public final class PhoneNumber { |
假设你试图在HashMap
中使用这个类:
1 | Map<PhoneNumber, String> m = new HashMap<PhoneNumber, String>(); |
这时候,你可能期待m.get(new PhoneNumber(707, 867, 5309))
返回Jenny
,但它返回空。注意涉及到两个PhoneNumber
实例:一个用来插入到HashMap
,第二个相等的实例用来(试图)检索。PhoneNumber
类没有重写hashCode
方法引起两个相等的实例有不等的哈希值,违反了hashCode
约定。因此get
方法可能在一个与put
方法储存的哈希桶不同的哈希桶中查找电话号码。即使两个实例碰到哈希到同一个桶中,get
几乎必定返回空,因为HashMap
缓存了每个输入相关的哈希吗,如果哈希码不匹配,不会检查对象的相等性。
修正这个问题很简单,为PhoneNumber
类提供一个合适的hashCode
方法。因此hashCode
方法应该看起来是什么样的?编写一个合法但不好的方法是没意义的。例如,下面的方法合法但从未被用到:
1 | // The worst possible legal hash function - never use! |
它是合法的因为它保证了相等的对象有同样的哈希值。它是极差的因为它保证了每个对象都有同样的哈希值。因此,每个对象哈希到相同的桶中,哈希表退化成链表。程序从应该运行在线性时间内变成运行在平方时间内。对于打的哈希表,这是工作和不工作的区别。
一个好的哈希函数对于不等的对象趋向于产生不等的哈希值。这与hashCode
约定中的第三条是一个意思。理想情况下,一个哈希函数应该将任何合理的不等的实例集合,统一散列在所有可能的哈希值上。要取得这样的目标是非常困难的。幸运的是不难取得一个公平的近似。下面是简单的流程:
存储一些非零常量值,例如17,存储在变量名为
result
的int
变量中。对于对象中每一个有意义的字段
f
(每一个equals
方法考虑的字段),按以下做法去做:
a. 为这个字段计算一个int
型的哈希码c
:
i. 如果这个字段是一个boolean
,计算(f ? 1 : 0)
。
ii. 如果这个字段是一个byte
,char
,short
或int
,计算(int) f
。
iii. 如果这个字段是一个long
,计算(int)(f^(f>>>32))
。
iv. 如果这个字段是一个float
,计算Float.floatToIntBits(f)
。
v. 如果这个字段是一个double
,计算Double.doubleToLongBits(f)
,然后对结果long
进行2.a.iii处理。
vi. 如果这个字段是一个对象引用并且这个类的equals
方法通过递归调用equals
方法来比较这个字段,那么对这个字段递归的调用hashCode
方法。如果需要更复杂的比较,为这个字段计算一个“标准表示”然后在标准表示上调用hashCode
方法。如果字段值为null
,返回0
(或一些其它常量,但0
是传统表示).
vii. 如果字段是一个数组,将它每一个元素看做是一个单独的字段。也就是说,通过递归的应用这些规则为每一个有效元素计算一个哈希值,并结合这些值对每一个用步骤2.b处理。如果数组的每个元素都是有意义的,你可以用JDK 1.5中的Arrays.hashCode
方法。
b. 结合步骤2.a计算的哈希码c
得到结果如下:result = 31 * result + c
;
返回结果。
当你完成了
hashCode
方法的编写后,问一下自己相等的对象是否有相同的哈希码。写单元测试来验证你的直觉!如果相等的实例有不等的哈希码弄明白为什么并修正这个问题。
你可以从哈希码计算中排除冗余字段。换句话说,你可以忽略那些可以从根据计算中的字段计算出值的字段。你必须排除那些equals
比较没有使用的字段,或者你冒险违反hashCode
约定中的第二条。
步骤1中使用了一个非零初始值,因此哈希值会受到哈希值为0的最初字段的影响,最初字段的哈希值是在步骤2.a中计算的。如果0作为初始值在步骤1中使用,全部的哈希值将不受任何这样的最初字段的影响,这将会增加哈希碰撞。
步骤2.b中的乘积使结果依赖于字段的顺序,如果这个类有多个相似的字段会取得一个更好的哈希函数。例如,String
哈希函数忽略了乘积,所有的字母顺序将有相同的哈希码。选择值31是因为它是一个奇素数。如果它是偶数并且乘积溢出,会损失信息,因为与2想乘等价于位移运算。使用一个素数的优势不是那么明显,但习惯上都使用素数。31的一个很好的特性是乘积可以用位移和减法运算替换从而取得更好的性能:31 * i == (i << 5) - i
。现代的虚拟机能自动进行排序的优化。让我们对PhoneNumber
类应用上面的步骤。这儿有三个字段,所有的类型缩写:
1 | public int hashCode() { |
因为这个方法返回一个简单的确定性运算的结果,唯一的输入是PhoneNumber
实例中的三个有效字段,很明显相等的PhoneNumber
有相等的哈希值。事实上,这个方法对于PhoneNumber
来说是一个完美的很好的hashCode
实现,与Java平台库的实现是等价的。它是简单的,相当的快,做者合理的工作——将不等的电话号码分散到不同的哈希桶里。
如果一个类是不可变的,计算哈希码的代价是很明显的,你可能想缓存对象中的哈希码而不是每次请求时重新计算它。如果你认为这种类型的大多数对象将作为哈希键使用,那当实例创建时你应该计算哈希码。此外,当第一次调用hashCode
时(Item 71),你可以选择延迟初始化。我们的PhoneNumber
类进行这样处理的优点不是很明显,但可以显示一下它是怎么做的:
1 | // Lazily initialized, cached hashCode |
不要试图将对象的有效部分排除在哈希码计算之外来提高性能。虽然最终结果的哈希函数可能运行更快,但它的质量很差可能会降低哈希表的性能,使哈希表变成慢的不可用的状态。尤其是在实践中,哈希函数可能面临在你选择忽略的区域中存在很大不同的实例集合。如果这种情况发生了,哈希函数会映射所有的实例到一个非常小的哈希码上,基于哈希的集合的性能将会变成平方级的。这不仅仅是一个理论问题。String
哈希函数在1.2之前的实现中,最多检查16个字符,整个字符串等间距,从第一个字符开始。对于名字分层的大集合,例如URLs,哈希函数正好展现了这里提到的病态行为。
Java平台库中的许多类,例如String
,Integer
和Date
,包含了类规范中它们的hashCode
方法返回的确定值。这通常不是一个好注意,因为它严重限制了你在将来版本中改进哈希函数的能力。如果没有指定哈希函数的细节,当发现有缺陷或一个更好的哈希函数时,你可以在接下来的版本中改变哈希函数,确信没有用户依赖哈希函数返回的确定值。
Item10: 总是重写toString方法
尽管java.lang.Object
提供了toString
方法的实现,但是通常情况下它返回的字符串不是使用类的用户想要的。返回的字符串包含类名,后面是一个@
符号加上哈希码的十六进制表示,例如PhoneNumber@163b91
。toString
的通用约定指出,返回值应该是“简洁但易读的信息表示”[JavaSE6]。虽然可以认为PhoneNumber@163b91
简洁易读,但它与(707) 867-5309
相比,它的信息不够丰富。toString
约定进一步指出,“建议所有的子类重写这个方法”。确实是个好建议。
虽然它不像遵守equals
和hashCode
约定(Item 8, Item 9)那样重要,但是提供一个好的toString
实现可以使你的类用起来更舒适。当对象传到println
,printf
,字符串连接操作符,或assert
中,或通过调试器打印时,会自动调用toString
方法。(Java 1.5版本中平台加入了printf
方法,相关的方法包括String.format
,类似于C语言中的sprintf
方法)。
如果你已经为PhoneNumber
提供了一个好的toString
方法,生成有用的诊断信息是很容易的:
1 | System.out.println("Failed to connect: " + phoneNumber); |
无论你是否重写toString
方法,程序员们都会以这种方式生成诊断信息,但除非你重写了toString
方法,否则这些信息是无用的。提供一个好的toString
方法的好处是除了类的实例之外,也扩展了包含这些实例引用的对象,尤其是集合。当打印一个映射时,{Jenny=PhoneNumber@163b91}
或{Jenny=(707) 867-5309}
你更喜欢哪一个?
当实践时,toString
方法应该返回包含在对象中的所有的感兴趣信息,正如刚才电话号码的例子展示的那样。如果对象很大或它包含不能用字符串表示的状态,重写toString
方法是不切实际的。在这种情况下,toString
应该返回一个概要信息,例如Manhattan white pages (1487536 listings)
或Thread[main,5,main]
。理想情况下,字符串应该是自解释的。(Thread
例子不能满足这样的要求。)
当实现toString
时,你要做的一个重要决定是是否在文档中指定返回值的形式。对于值类建议你这样做,例如电话号码或矩阵。指定返回值形式的优势在于它能为对象提供一个标准的,清晰的,可读的表示。这个表示可以用在输入输出中,也可以用在一致的可读数据对象中,例如XML文档。如果你指定了这个形式,提供一个匹配的静态工厂或构造函数通常是一个好主意,程序员可以很容易地在对象和它的字符串表示之间来回转换。Java平台库中许多值类都采用了这个方法,包括BigInteger
,BigDecimal
和大多数基本类型的包装类。
指定toString
返回值形式的劣势在于一旦你指定了它,假设你的类被广泛使用,你就必须一直坚持它。程序员将会写代码转换这种表示,产生这种形式并将它嵌入到持久化数据中。如果你在将来的版本中更改了表示形式,你将会破坏他们的代码和数据,他们将会抱怨。如果你没有指定一个形式,你保留了添加信息的灵活性或者在后续版本改进这种形式。
无论你决定是否指定格式,你都应该清楚地表明你的意图。如果你指定了格式,你应该准确的去做。例如,下面的Item 9中PhoneNumber
类的toString
方法:
1 | /** |
如果你没有指定格式,文档注释读起来应该如下:
1 | /** |
写代码或持久化数据的依赖于格式细节的程序员,在读了这个文档之后,一旦格式改变,只能自己负责后果。
无论你是否指定了格式,都应该提供toString
返回值中包含的所有信息的程序访问接口。例如,PhoneNumber
类应该包含区域码,前缀和行号的访问器。如果你没有这样做,你会迫使需要这个信息的程序员取转换这个字符串。除了为程序员降低效率和造成不必要的工作之外,这个过程中很容易出错,而且会导致系统非常脆弱,如果你更改了格式系统会崩溃。如果没有提供访问器,即使你指明了字符串格式是可以变化的,这个字符串格式也变成了实际上的API。